เรียนรู้การทดสอบเชิงคุณสมบัติด้วย Hypothesis ใน Python ที่จะช่วยให้คุณก้าวข้ามการทดสอบแบบเดิมๆ เพื่อค้นหา edge case และสร้างซอฟต์แวร์ที่แข็งแกร่งและเชื่อถือได้ยิ่งขึ้น
ก้าวข้าม Unit Tests: เจาะลึกการทดสอบเชิงคุณสมบัติด้วย Hypothesis ใน Python
ในโลกของการพัฒนาซอฟต์แวร์ การทดสอบคือรากฐานของคุณภาพ ตลอดหลายทศวรรษที่ผ่านมา กระบวนทัศน์หลักคือ การทดสอบแบบอิงตามตัวอย่าง (example-based testing) เราสร้างอินพุตอย่างพิถีพิถัน กำหนดผลลัพธ์ที่คาดหวัง และเขียน assertions เพื่อตรวจสอบว่าโค้ดของเราทำงานตามที่วางแผนไว้ แนวทางนี้ ซึ่งพบได้ในเฟรมเวิร์กอย่าง unittest
และ pytest
นั้นทรงพลังและจำเป็นอย่างยิ่ง แต่ถ้าผมบอกคุณว่ามีแนวทางเสริมที่สามารถค้นหาบั๊กที่คุณไม่เคยคิดจะมองหามาก่อนล่ะ
ขอต้อนรับสู่โลกของ การทดสอบเชิงคุณสมบัติ (property-based testing) กระบวนทัศน์ที่เปลี่ยนจุดสนใจจากการทดสอบตัวอย่างที่เฉพาะเจาะจงไปสู่การตรวจสอบคุณสมบัติทั่วไปของโค้ดของคุณ และในระบบนิเวศของ Python ผู้ที่เป็นแชมป์ในแนวทางนี้อย่างไม่มีใครเทียบได้คือไลบรารีที่ชื่อว่า Hypothesis
คู่มือฉบับสมบูรณ์นี้จะนำคุณจากผู้เริ่มต้นไปสู่ผู้ปฏิบัติการทดสอบเชิงคุณสมบัติด้วย Hypothesis อย่างมั่นใจ เราจะสำรวจแนวคิดหลัก เจาะลึกตัวอย่างที่ใช้งานได้จริง และเรียนรู้วิธีผสานรวมเครื่องมืออันทรงพลังนี้เข้ากับขั้นตอนการพัฒนาประจำวันของคุณเพื่อสร้างซอฟต์แวร์ที่แข็งแกร่งขึ้น เชื่อถือได้มากขึ้น และทนทานต่อบั๊กมากขึ้น
การทดสอบเชิงคุณสมบัติคืออะไร? การปรับเปลี่ยนกระบวนทัศน์ทางความคิด
เพื่อให้เข้าใจ Hypothesis เราต้องเข้าใจแนวคิดพื้นฐานของการทดสอบเชิงคุณสมบัติก่อน ลองเปรียบเทียบกับการทดสอบแบบอิงตามตัวอย่างที่เราคุ้นเคยกันดี
การทดสอบแบบอิงตามตัวอย่าง: เส้นทางที่คุ้นเคย
สมมติว่าคุณได้เขียนฟังก์ชันจัดเรียงข้อมูลแบบกำหนดเองชื่อ my_sort()
ด้วยการทดสอบแบบอิงตามตัวอย่าง กระบวนการคิดของคุณจะเป็นดังนี้:
- "ลองทดสอบกับลิสต์ที่เรียงลำดับแล้วแบบง่ายๆ" ->
assert my_sort([1, 2, 3]) == [1, 2, 3]
- "แล้วถ้าเป็นลิสต์ที่เรียงจากหลังมาหน้าล่ะ?" ->
assert my_sort([3, 2, 1]) == [1, 2, 3]
- "แล้วถ้าเป็นลิสต์ว่าง?" ->
assert my_sort([]) == []
- "ลิสต์ที่มีข้อมูลซ้ำ?" ->
assert my_sort([5, 1, 5, 2]) == [1, 2, 5, 5]
- "และลิสต์ที่มีเลขติดลบ?" ->
assert my_sort([-1, -5, 0]) == [-5, -1, 0]
วิธีนี้มีประสิทธิภาพ แต่ก็มีข้อจำกัดพื้นฐานคือ: คุณกำลังทดสอบเฉพาะกรณีที่คุณนึกถึงเท่านั้น การทดสอบของคุณจะดีได้เท่ากับจินตนาการของคุณ คุณอาจพลาด edge case ที่เกี่ยวกับตัวเลขขนาดใหญ่มาก ความไม่แม่นยำของทศนิยม ตัวอักษร unicode ที่เฉพาะเจาะจง หรือการผสมผสานข้อมูลที่ซับซ้อนซึ่งนำไปสู่พฤติกรรมที่ไม่คาดคิด
การทดสอบเชิงคุณสมบัติ: คิดในเชิงคุณสมบัติที่ไม่เปลี่ยนแปลง (Invariants)
การทดสอบเชิงคุณสมบัติจะเปลี่ยนแนวทางโดยสิ้นเชิง แทนที่จะให้ตัวอย่างที่เฉพาะเจาะจง คุณจะกำหนด คุณสมบัติ (properties) หรือ อินแวเรียนต์ (invariants) ของฟังก์ชันของคุณ ซึ่งเป็นกฎที่ควรจะเป็นจริงสำหรับอินพุตที่ถูกต้องใดๆ ก็ตาม สำหรับฟังก์ชัน my_sort()
ของเรา คุณสมบัติเหล่านี้อาจเป็น:
- ผลลัพธ์ถูกจัดเรียงแล้ว: สำหรับลิสต์ของตัวเลขใดๆ ทุกองค์ประกอบในลิสต์ผลลัพธ์จะต้องน้อยกว่าหรือเท่ากับองค์ประกอบที่ตามมา
- ผลลัพธ์มีองค์ประกอบเหมือนกับอินพุต: ลิสต์ที่จัดเรียงแล้วเป็นเพียงการเรียงสับเปลี่ยนของลิสต์ดั้งเดิม ไม่มีการเพิ่มหรือสูญเสียองค์ประกอบใดๆ
- ฟังก์ชันเป็น Idempotent: การจัดเรียงลิสต์ที่ถูกจัดเรียงแล้วไม่ควรเปลี่ยนแปลงลิสต์นั้น กล่าวคือ
my_sort(my_sort(some_list)) == my_sort(some_list)
ด้วยแนวทางนี้ คุณไม่ได้เป็นคนเขียนข้อมูลทดสอบ แต่คุณกำลังเขียนกฎเกณฑ์ จากนั้นคุณปล่อยให้เฟรมเวิร์กอย่าง Hypothesis สร้างอินพุตแบบสุ่มที่หลากหลายและมักจะซับซ้อนคาดไม่ถึงนับร้อยหรือนับพันรายการเพื่อพยายามพิสูจน์ว่าคุณสมบัติของคุณผิด หากมันพบอินพุตที่ทำลายคุณสมบัติ มันก็พบบั๊กนั่นเอง
ขอแนะนำ Hypothesis: เครื่องมือสร้างข้อมูลทดสอบอัตโนมัติของคุณ
Hypothesis เป็นไลบรารีการทดสอบเชิงคุณสมบัติชั้นนำสำหรับ Python มันจะรับคุณสมบัติที่คุณกำหนดและทำงานหนักในการสร้างข้อมูลทดสอบเพื่อท้าทายคุณสมบัติเหล่านั้น มันไม่ใช่แค่เครื่องสร้างข้อมูลแบบสุ่ม แต่เป็นเครื่องมือที่ชาญฉลาดและทรงพลังซึ่งออกแบบมาเพื่อค้นหาบั๊กอย่างมีประสิทธิภาพ
ฟีเจอร์หลักของ Hypothesis
- การสร้างกรณีทดสอบอัตโนมัติ: คุณกำหนด *รูปแบบ* ของข้อมูลที่คุณต้องการ (เช่น "ลิสต์ของจำนวนเต็ม" "สตริงที่ประกอบด้วยตัวอักษรเท่านั้น" "datetime ในอนาคต") และ Hypothesis จะสร้างตัวอย่างที่หลากหลายตามรูปแบบนั้น
- การลดรูปตัวอย่างที่ชาญฉลาด (Intelligent Shrinking): นี่คือฟีเจอร์เด็ด เมื่อ Hypothesis พบกรณีทดสอบที่ล้มเหลว (เช่น ลิสต์ของจำนวนเชิงซ้อน 50 ตัวที่ทำให้ฟังก์ชัน sort ของคุณพัง) มันจะไม่เพียงแค่รายงานลิสต์ขนาดใหญ่นั้น มันจะลดความซับซ้อนของอินพุตโดยอัตโนมัติและอย่างชาญฉลาดเพื่อค้นหา ตัวอย่างที่เล็กที่สุดเท่าที่จะเป็นไปได้ ที่ยังคงทำให้เกิดข้อผิดพลาด แทนที่จะเป็นลิสต์ 50 รายการ มันอาจรายงานว่าข้อผิดพลาดเกิดขึ้นกับแค่
[inf, nan]
สิ่งนี้ทำให้การดีบักรวดเร็วและมีประสิทธิภาพอย่างเหลือเชื่อ - การผสานรวมที่ไร้รอยต่อ: Hypothesis ผสานรวมเข้ากับเฟรมเวิร์กการทดสอบยอดนิยมอย่าง
pytest
และunittest
ได้อย่างสมบูรณ์แบบ คุณสามารถเพิ่มการทดสอบเชิงคุณสมบัติควบคู่ไปกับการทดสอบแบบอิงตามตัวอย่างที่มีอยู่ได้โดยไม่ต้องเปลี่ยนขั้นตอนการทำงานของคุณ - ไลบรารี Strategies ที่หลากหลาย: มันมาพร้อมกับคอลเลกชัน "strategies" (กลยุทธ์) ในตัวจำนวนมาก สำหรับการสร้างทุกสิ่งตั้งแต่จำนวนเต็มและสตริงธรรมดาไปจนถึงโครงสร้างข้อมูลที่ซับซ้อนและซ้อนกัน, datetime ที่รับรู้โซนเวลา และแม้กระทั่ง NumPy arrays
- การทดสอบเชิงสถานะ (Stateful Testing): สำหรับระบบที่ซับซ้อนมากขึ้น Hypothesis สามารถทดสอบลำดับของการกระทำเพื่อค้นหาบั๊กในการเปลี่ยนสถานะ ซึ่งเป็นสิ่งที่ทำได้ยากมากกับการทดสอบแบบอิงตามตัวอย่าง
เริ่มต้นใช้งาน: การทดสอบ Hypothesis ครั้งแรกของคุณ
มาลงมือทำกันเลยดีกว่า วิธีที่ดีที่สุดในการทำความเข้าใจ Hypothesis คือการได้เห็นมันทำงานจริง
การติดตั้ง
ก่อนอื่น คุณจะต้องติดตั้ง Hypothesis และ test runner ที่คุณเลือก (เราจะใช้ pytest
) ง่ายๆ แค่นี้:
pip install pytest hypothesis
ตัวอย่างง่ายๆ: ฟังก์ชันค่าสัมบูรณ์
ลองพิจารณาฟังก์ชันง่ายๆ ที่ควรจะคำนวณค่าสัมบูรณ์ของตัวเลข การนำไปใช้งานที่มีบั๊กเล็กน้อยอาจมีลักษณะดังนี้:
# ในไฟล์ชื่อ `my_math.py` def custom_abs(x): """ฟังก์ชันค่าสัมบูรณ์ที่สร้างขึ้นเอง""" if x < 0: return -x return x
ตอนนี้ มาเขียนไฟล์ทดสอบ test_my_math.py
กัน เริ่มจากแนวทาง pytest
แบบดั้งเดิมก่อน:
# test_my_math.py (แบบอิงตามตัวอย่าง) def test_abs_positive(): assert custom_abs(5) == 5 def test_abs_negative(): assert custom_abs(-5) == 5 def test_abs_zero(): assert custom_abs(0) == 0
การทดสอบเหล่านี้ผ่านทั้งหมด ฟังก์ชันของเราดูเหมือนจะถูกต้องตามตัวอย่างเหล่านี้ แต่ตอนนี้ เรามาเขียนการทดสอบเชิงคุณสมบัติด้วย Hypothesis กัน คุณสมบัติหลักของฟังก์ชันค่าสัมบูรณ์คืออะไร? ผลลัพธ์ต้องไม่เป็นค่าลบ
# test_my_math.py (เชิงคุณสมบัติด้วย Hypothesis) from hypothesis import given from hypothesis import strategies as st from my_math import custom_abs @given(st.integers()) def test_abs_property_is_non_negative(x): """คุณสมบัติ: ค่าสัมบูรณ์ของจำนวนเต็มใดๆ จะต้อง >= 0 เสมอ""" assert custom_abs(x) >= 0
มาดูรายละเอียดกัน:
from hypothesis import given, strategies as st
: เรานำเข้าส่วนประกอบที่จำเป็นgiven
คือเดคคอเรเตอร์ที่เปลี่ยนฟังก์ชันทดสอบธรรมดาให้เป็นการทดสอบเชิงคุณสมบัติstrategies
คือโมดูลที่เราใช้หาเครื่องมือสร้างข้อมูลของเรา@given(st.integers())
: นี่คือหัวใจของการทดสอบ เดคคอเรเตอร์@given
บอกให้ Hypothesis รันฟังก์ชันทดสอบนี้หลายครั้ง ในแต่ละครั้ง มันจะสร้างค่าโดยใช้ strategy ที่ให้มาคือst.integers()
และส่งค่านั้นเป็นอาร์กิวเมนต์x
ไปยังฟังก์ชันทดสอบของเราassert custom_abs(x) >= 0
: นี่คือคุณสมบัติของเรา เรายืนยันว่าสำหรับจำนวนเต็มx
ใดๆ ที่ Hypothesis สร้างขึ้นมา ผลลัพธ์ของฟังก์ชันของเราจะต้องมากกว่าหรือเท่ากับศูนย์
เมื่อคุณรันโค้ดนี้ด้วย pytest
มันน่าจะผ่านสำหรับหลายๆ ค่า Hypothesis จะลอง 0, -1, 1, เลขบวกค่ามากๆ, เลขลบค่ามากๆ และอื่นๆ อีกมากมาย ฟังก์ชันง่ายๆ ของเรารับมือกับทั้งหมดนี้ได้อย่างถูกต้อง ตอนนี้ลองใช้ strategy อื่นเพื่อดูว่าเราจะหาจุดอ่อนได้หรือไม่
# ลองทดสอบกับเลขทศนิยม @given(st.floats()) def test_abs_floats_property(x): assert custom_abs(x) >= 0
ถ้าคุณรันโค้ดนี้ Hypothesis จะพบกรณีที่ล้มเหลวอย่างรวดเร็ว!
Falsifying example: test_abs_floats_property(x=nan) ... assert custom_abs(nan) >= 0 AssertionError: assert nan >= 0
Hypothesis ค้นพบว่าฟังก์ชันของเราเมื่อได้รับ float('nan')
(Not a Number) จะคืนค่า nan
ออกมา ซึ่ง assertion nan >= 0
เป็นเท็จ เราเพิ่งพบบั๊กเล็กๆ น้อยๆ ที่เราอาจไม่เคยคิดจะทดสอบด้วยตนเอง เราสามารถแก้ไขฟังก์ชันของเราเพื่อจัดการกับกรณีนี้ได้ เช่น อาจจะให้ raise ValueError
หรือคืนค่าเฉพาะเจาะจง
ยิ่งไปกว่านั้น ถ้าบั๊กเกิดจากเลขทศนิยมที่เฉพาะเจาะจงมากๆ ตัว shrinker ของ Hypothesis ก็จะทำการลดรูปตัวเลขที่ซับซ้อนที่ทำให้เกิดข้อผิดพลาดนั้น ให้กลายเป็นเวอร์ชันที่ง่ายที่สุดที่ยังคงทำให้เกิดบั๊กได้
พลังของ Strategies: การสร้างข้อมูลทดสอบของคุณ
Strategies คือหัวใจของ Hypothesis มันคือสูตรสำหรับการสร้างข้อมูล ไลบรารีนี้มี strategies ในตัวมากมาย และคุณสามารถรวมและปรับแต่งมันเพื่อสร้างโครงสร้างข้อมูลแทบทุกอย่างที่คุณจะจินตนาการได้
Strategies ในตัวที่ใช้บ่อย
- ตัวเลข (Numeric):
st.integers(min_value=0, max_value=1000)
: สร้างจำนวนเต็ม ซึ่งสามารถกำหนดช่วงที่ต้องการได้st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
: สร้างเลขทศนิยม พร้อมการควบคุมค่าพิเศษต่างๆ ได้อย่างละเอียดst.fractions()
,st.decimals()
- ข้อความ (Text):
st.text(min_size=1, max_size=50)
: สร้างสตริง unicode ที่มีความยาวตามกำหนดst.text(alphabet='abcdef0123456789')
: สร้างสตริงจากชุดตัวอักษรที่ระบุ (เช่น สำหรับรหัส hex)st.characters()
: สร้างตัวอักษรเดี่ยวๆ
- คอลเลกชัน (Collections):
st.lists(st.integers(), min_size=1)
: สร้างลิสต์ที่แต่ละองค์ประกอบเป็นจำนวนเต็ม สังเกตว่าเราส่ง strategy อื่นเป็นอาร์กิวเมนต์! นี่เรียกว่าการประกอบ (composition)st.tuples(st.text(), st.booleans())
: สร้าง tuple ที่มีโครงสร้างคงที่st.sets(st.integers())
st.dictionaries(keys=st.text(), values=st.integers())
: สร้าง dictionary ที่มีคีย์และค่าตามประเภทที่ระบุ
- เวลา (Temporal):
st.dates()
,st.times()
,st.datetimes()
,st.timedeltas()
สิ่งเหล่านี้สามารถทำให้รับรู้โซนเวลาได้
- เบ็ดเตล็ด (Miscellaneous):
st.booleans()
: สร้างTrue
หรือFalse
st.just('constant_value')
: สร้างค่าเดียวเดิมๆ เสมอ มีประโยชน์สำหรับการประกอบ strategies ที่ซับซ้อนst.one_of(st.integers(), st.text())
: สร้างค่าจากหนึ่งใน strategies ที่ให้มาst.none()
: สร้างเฉพาะNone
การรวมและแปลง Strategies
พลังที่แท้จริงของ Hypothesis มาจากความสามารถในการสร้าง strategies ที่ซับซ้อนจากอันที่ง่ายกว่า
การใช้ .map()
เมธอด .map()
ช่วยให้คุณนำค่าจาก strategy หนึ่งมาแปลงเป็นสิ่งอื่นได้ เหมาะอย่างยิ่งสำหรับการสร้างอ็อบเจกต์ของคลาสที่คุณกำหนดเอง
# data class แบบง่ายๆ from dataclasses import dataclass @dataclass class User: user_id: int username: str # strategy สำหรับสร้างอ็อบเจกต์ User user_strategy = st.builds( User, user_id=st.integers(min_value=1), username=st.text(min_size=3, alphabet='abcdefghijklmnopqrstuvwxyz') ) @given(user=user_strategy) def test_user_creation(user): assert isinstance(user, User) assert user.user_id > 0 assert user.username.isalpha()
การใช้ .filter()
และ assume()
บางครั้งคุณจำเป็นต้องปฏิเสธค่าที่สร้างขึ้นมาบางค่า ตัวอย่างเช่น คุณอาจต้องการลิสต์ของจำนวนเต็มที่ผลรวมไม่เท่ากับศูนย์ คุณสามารถใช้ .filter()
ได้:
st.lists(st.integers()).filter(lambda x: sum(x) != 0)
อย่างไรก็ตาม การใช้ .filter()
อาจไม่มีประสิทธิภาพ หากเงื่อนไขเป็นเท็จบ่อยครั้ง Hypothesis อาจใช้เวลานานในการพยายามสร้างตัวอย่างที่ถูกต้อง แนวทางที่ดีกว่ามักจะเป็นการใช้ assume()
ภายในฟังก์ชันทดสอบของคุณ:
from hypothesis import assume @given(st.lists(st.integers())) def test_something_with_non_zero_sum_list(numbers): assume(sum(numbers) != 0) # ... ตรรกะการทดสอบของคุณอยู่ที่นี่ ...
assume()
บอกกับ Hypothesis ว่า: "ถ้าเงื่อนไขนี้ไม่เป็นจริง ก็แค่ทิ้งตัวอย่างนี้ไปแล้วลองตัวใหม่" มันเป็นวิธีที่ตรงไปตรงมาและมักจะมีประสิทธิภาพมากกว่าในการจำกัดข้อมูลทดสอบของคุณ
การใช้ st.composite()
สำหรับการสร้างข้อมูลที่ซับซ้อนจริงๆ ซึ่งค่าที่สร้างขึ้นค่าหนึ่งขึ้นอยู่กับอีกค่าหนึ่ง st.composite()
คือเครื่องมือที่คุณต้องการ มันช่วยให้คุณเขียนฟังก์ชันที่รับฟังก์ชันพิเศษ draw
เป็นอาร์กิวเมนต์ ซึ่งคุณสามารถใช้ดึงค่าจาก strategies อื่นๆ ทีละขั้นตอนได้
ตัวอย่างคลาสสิกคือการสร้างลิสต์และดัชนีที่ถูกต้องสำหรับลิสต์นั้น
@st.composite def list_and_index(draw): # ขั้นแรก ดึงลิสต์ที่ไม่ว่างเปล่า my_list = draw(st.lists(st.integers(), min_size=1)) # จากนั้น ดึงดัชนีที่รับประกันว่าจะถูกต้องสำหรับลิสต์นั้น index = draw(st.integers(min_value=0, max_value=len(my_list) - 1)) return (my_list, index) @given(data=list_and_index()) def test_list_access(data): my_list, index = data # การเข้าถึงนี้รับประกันว่าจะปลอดภัยเพราะวิธีที่เราสร้าง strategy element = my_list[index] assert element is not None # assertion ง่ายๆ
Hypothesis ในการใช้งานจริง: สถานการณ์ในโลกแห่งความเป็นจริง
ลองนำแนวคิดเหล่านี้ไปใช้กับปัญหาที่สมจริงมากขึ้นที่นักพัฒนาซอฟต์แวร์ต้องเผชิญทุกวัน
สถานการณ์ที่ 1: การทดสอบฟังก์ชัน Data Serialization
ลองนึกภาพฟังก์ชันที่แปลงข้อมูลโปรไฟล์ผู้ใช้ (dictionary) ให้อยู่ในรูปแบบสตริงที่ปลอดภัยสำหรับ URL และอีกฟังก์ชันหนึ่งที่แปลงกลับ คุณสมบัติที่สำคัญคือกระบวนการนี้ควรจะย้อนกลับได้อย่างสมบูรณ์
import json import base64 def serialize_profile(data: dict) -> str: """แปลง dictionary เป็นสตริง base64 ที่ปลอดภัยสำหรับ URL""" json_string = json.dumps(data) return base64.urlsafe_b64encode(json_string.encode('utf-8')).decode('utf-8') def deserialize_profile(encoded_str: str) -> dict: """แปลงสตริงกลับเป็น dictionary""" json_string = base64.urlsafe_b64decode(encoded_str.encode('utf-8')).decode('utf-8') return json.loads(json_string) # ตอนนี้มาทดสอบกัน # เราต้องการ strategy ที่สร้าง dictionary ที่เข้ากันได้กับ JSON json_dictionaries = st.dictionaries( keys=st.text(), values=st.recursive(st.none() | st.booleans() | st.floats(allow_nan=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10) ) @given(profile=json_dictionaries) def test_serialization_roundtrip(profile): """คุณสมบัติ: การแปลงกลับโปรไฟล์ที่เข้ารหัสควรได้โปรไฟล์เดิมคืนมา""" encoded = serialize_profile(profile) decoded = deserialize_profile(encoded) assert profile == decoded
การทดสอบเพียงครั้งเดียวนี้จะทดสอบฟังก์ชันของเราอย่างหนักหน่วงด้วยข้อมูลที่หลากหลายมหาศาล: dictionary ว่าง, dictionary ที่มีลิสต์ซ้อนกัน, dictionary ที่มีอักขระ unicode, dictionary ที่มีคีย์แปลกๆ และอื่นๆ อีกมากมาย ซึ่งละเอียดถี่ถ้วนกว่าการเขียนตัวอย่างด้วยตนเองเพียงไม่กี่ตัวอย่างมาก
สถานการณ์ที่ 2: การทดสอบอัลกอริทึมการเรียงลำดับ
กลับมาที่ตัวอย่างการเรียงลำดับของเรา นี่คือวิธีที่คุณจะทดสอบคุณสมบัติที่เราได้กำหนดไว้ก่อนหน้านี้
from collections import Counter def my_buggy_sort(numbers): # ลองใส่บั๊กเล็กๆ น้อยๆ: มันจะลบข้อมูลที่ซ้ำกันออกไป return sorted(list(set(numbers))) @given(st.lists(st.integers())) def test_sorting_properties(numbers): sorted_list = my_buggy_sort(numbers) # คุณสมบัติที่ 1: ผลลัพธ์ถูกจัดเรียงแล้ว for i in range(len(sorted_list) - 1): assert sorted_list[i] <= sorted_list[i+1] # คุณสมบัติที่ 2: องค์ประกอบเหมือนเดิม (อันนี้จะเจอบั๊ก) assert Counter(numbers) == Counter(sorted_list) # คุณสมบัติที่ 3: ฟังก์ชันเป็น idempotent assert my_buggy_sort(sorted_list) == sorted_list
เมื่อคุณรันการทดสอบนี้ Hypothesis จะพบตัวอย่างที่ล้มเหลวสำหรับคุณสมบัติที่ 2 อย่างรวดเร็ว เช่น numbers=[0, 0]
ฟังก์ชันของเราคืนค่า [0]
และ Counter([0, 0])
ไม่เท่ากับ Counter([0])
ตัว shrinker จะช่วยให้แน่ใจว่าตัวอย่างที่ล้มเหลวนั้นง่ายที่สุดเท่าที่จะเป็นไปได้ ทำให้เห็นสาเหตุของบั๊กได้ทันที
สถานการณ์ที่ 3: การทดสอบเชิงสถานะ (Stateful Testing)
สำหรับอ็อบเจกต์ที่มีสถานะภายในที่เปลี่ยนแปลงตลอดเวลา (เช่น การเชื่อมต่อฐานข้อมูล, ตะกร้าสินค้า หรือแคช) การค้นหาบั๊กอาจทำได้ยากอย่างเหลือเชื่อ อาจต้องใช้ลำดับการทำงานที่เฉพาะเจาะจงเพื่อทำให้เกิดข้อผิดพลาด Hypothesis มี `RuleBasedStateMachine` สำหรับจุดประสงค์นี้โดยเฉพาะ
ลองนึกภาพ API ง่ายๆ สำหรับ key-value store ในหน่วยความจำ:
class SimpleKeyValueStore: def __init__(self): self._data = {} def set(self, key, value): self._data[key] = value def get(self, key): return self._data.get(key) def delete(self, key): if key in self._data: del self._data[key] def size(self): return len(self._data)
เราสามารถจำลองพฤติกรรมและทดสอบมันด้วย state machine:
from hypothesis.stateful import RuleBasedStateMachine, rule, Bundle class KeyValueStoreMachine(RuleBasedStateMachine): def __init__(self): super().__init__() self.model = {} self.sut = SimpleKeyValueStore() # sut = System Under Test # Bundle() ใช้สำหรับส่งข้อมูลระหว่าง rules keys = Bundle('keys') @rule(target=keys, key=st.text(), value=st.integers()) def set_key(self, key, value): self.model[key] = value self.sut.set(key, value) return key @rule(key=keys) def delete_key(self, key): del self.model[key] self.sut.delete(key) @rule(key=st.text()) def get_key(self, key): model_val = self.model.get(key) sut_val = self.sut.get(key) assert model_val == sut_val @rule() def check_size(self): assert len(self.model) == self.sut.size() # ในการรันการทดสอบ คุณเพียงแค่ subclass จาก machine และ unittest.TestCase # ใน pytest คุณสามารถกำหนด test ให้กับ machine class ได้เลย TestKeyValueStore = KeyValueStoreMachine.TestCase
ตอนนี้ Hypothesis จะดำเนินการลำดับการทำงานแบบสุ่มของ `set_key`, `delete_key`, `get_key` และ `check_size` เพื่อพยายามค้นหาลำดับที่ทำให้ assertion ใดๆ ล้มเหลวอย่างไม่ลดละ มันจะตรวจสอบว่าการ get คีย์ที่ถูกลบไปแล้วทำงานถูกต้องหรือไม่, ขนาดของ store สอดคล้องกันหรือไม่หลังจากการ set และ delete หลายครั้ง และสถานการณ์อื่นๆ อีกมากมายที่คุณอาจไม่เคยคิดจะทดสอบด้วยตนเอง
แนวปฏิบัติที่ดีที่สุดและเคล็ดลับขั้นสูง
- ฐานข้อมูลตัวอย่าง (The Example Database): Hypothesis ฉลาดมาก เมื่อมันพบบั๊ก มันจะบันทึกตัวอย่างที่ล้มเหลวไว้ในไดเร็กทอรีท้องถิ่น (
.hypothesis/
) ครั้งต่อไปที่คุณรันการทดสอบ มันจะเล่นซ้ำตัวอย่างที่ล้มเหลวนั้นก่อน เพื่อให้คุณได้รับฟีดแบ็กทันทีว่าบั๊กยังคงอยู่ เมื่อคุณแก้ไขแล้ว ตัวอย่างนั้นจะไม่ถูกเล่นซ้ำอีก - ควบคุมการทำงานของเทสต์ด้วย
@settings
: คุณสามารถควบคุมหลายแง่มุมของการรันเทสต์ได้โดยใช้เดคคอเรเตอร์@settings
คุณสามารถเพิ่มจำนวนตัวอย่าง, ตั้งเวลาสูงสุดที่ตัวอย่างหนึ่งๆ สามารถรันได้ (เพื่อจับ infinite loops) และปิดการตรวจสอบบางอย่างได้@settings(max_examples=500, deadline=1000) # รัน 500 ตัวอย่าง, กำหนดเวลา 1 วินาที @given(...) ...
- การทำซ้ำข้อผิดพลาด (Reproducing Failures): ทุกครั้งที่ Hypothesis รัน จะมีการพิมพ์ค่า seed ออกมา (เช่น
@reproduce_failure('version', 'seed')
) หากเซิร์ฟเวอร์ CI พบบั๊กที่คุณไม่สามารถทำซ้ำได้ในเครื่องของคุณ คุณสามารถใช้เดคคอเรเตอร์นี้พร้อมกับ seed ที่ให้มาเพื่อบังคับให้ Hypothesis รันลำดับตัวอย่างเดียวกันเป๊ะ - การผสานรวมกับ CI/CD: Hypothesis เหมาะอย่างยิ่งสำหรับไปป์ไลน์ continuous integration ใดๆ ความสามารถในการค้นหาบั๊กที่ซ่อนเร้นก่อนที่จะไปถึง production ทำให้มันเป็นเครือข่ายความปลอดภัยที่ประเมินค่าไม่ได้
การปรับเปลี่ยนกระบวนทัศน์ทางความคิด: การคิดในเชิงคุณสมบัติ
การนำ Hypothesis มาใช้เป็นมากกว่าการเรียนรู้ไลบรารีใหม่ มันคือการยอมรับวิธีคิดใหม่เกี่ยวกับความถูกต้องของโค้ดของคุณ แทนที่จะถามว่า "ฉันควรทดสอบอินพุตอะไรบ้าง?" คุณจะเริ่มถามว่า "ความจริงสากลเกี่ยวกับโค้ดนี้คืออะไร?"
นี่คือคำถามบางส่วนที่จะช่วยนำทางคุณเมื่อพยายามระบุคุณสมบัติ:
- มีการดำเนินการย้อนกลับหรือไม่? (เช่น serialize/deserialize, encrypt/decrypt, compress/decompress) คุณสมบัติคือการดำเนินการไปและย้อนกลับควรให้ผลลัพธ์เป็นอินพุตดั้งเดิม
- การดำเนินการนั้นเป็น idempotent หรือไม่? (เช่น
abs(abs(x)) == abs(x)
) การใช้ฟังก์ชันมากกว่าหนึ่งครั้งควรให้ผลลัพธ์เช่นเดียวกับการใช้เพียงครั้งเดียว - มีวิธีอื่นที่ง่ายกว่าในการคำนวณผลลัพธ์เดียวกันหรือไม่? คุณสามารถทดสอบได้ว่าฟังก์ชันที่ซับซ้อนและปรับให้เหมาะสมของคุณให้ผลลัพธ์เหมือนกับเวอร์ชันที่เรียบง่ายและถูกต้องอย่างเห็นได้ชัด (เช่น การทดสอบฟังก์ชัน sort สุดเจ๋งของคุณกับฟังก์ชัน
sorted()
ที่มีใน Python) - อะไรที่ควรจะเป็นจริงเสมอเกี่ยวกับผลลัพธ์? (เช่น ผลลัพธ์ของฟังก์ชัน `find_prime_factors` ควรประกอบด้วยจำนวนเฉพาะเท่านั้น และผลคูณของพวกมันควรเท่ากับอินพุต)
- สถานะเปลี่ยนแปลงอย่างไร? (สำหรับการทดสอบเชิงสถานะ) อินแวเรียนต์อะไรที่ต้องคงไว้หลังจากการดำเนินการที่ถูกต้องใดๆ? (เช่น จำนวนสินค้าในตะกร้าสินค้าต้องไม่เป็นค่าลบ)
สรุป: ความมั่นใจในระดับใหม่
การทดสอบเชิงคุณสมบัติด้วย Hypothesis ไม่ได้มาแทนที่การทดสอบแบบอิงตามตัวอย่าง คุณยังคงต้องการการทดสอบที่เขียนขึ้นเองอย่างเฉพาะเจาะจงสำหรับตรรกะทางธุรกิจที่สำคัญและข้อกำหนดที่เข้าใจกันดี (เช่น "ผู้ใช้จากประเทศ X ต้องเห็นราคา Y")
สิ่งที่ Hypothesis มอบให้คือวิธีการสำรวจพฤติกรรมของโค้ดของคุณแบบอัตโนมัติและทรงพลัง และป้องกัน edge case ที่ไม่คาดฝัน มันทำหน้าที่เป็นคู่หูที่ไม่รู้จักเหน็ดเหนื่อย สร้างการทดสอบนับพันที่หลากหลายและซับซ้อนเกินกว่าที่มนุษย์จะเขียนขึ้นได้จริง ด้วยการกำหนดคุณสมบัติพื้นฐานของโค้ดของคุณ คุณกำลังสร้างข้อกำหนดที่แข็งแกร่งซึ่ง Hypothesis สามารถทดสอบได้ ทำให้คุณมีความมั่นใจในซอฟต์แวร์ของคุณในระดับใหม่
ครั้งต่อไปที่คุณเขียนฟังก์ชัน ลองใช้เวลาสักครู่เพื่อคิดให้ไกลกว่าแค่ตัวอย่าง ถามตัวเองว่า "กฎคืออะไร? อะไรที่ต้องเป็นจริงเสมอ?" จากนั้น ให้ Hypothesis ทำงานหนักในการพยายามทำลายกฎเหล่านั้น คุณจะประหลาดใจกับสิ่งที่มันพบ และโค้ดของคุณจะดีขึ้นอย่างแน่นอน